Skip to content

feat(mcp): platform + user auth resolvers — reads via platform, writes via user (§19)#326

Merged
initializ-mk merged 1 commit into
mainfrom
feat/platform-token-resolver
Jul 17, 2026
Merged

feat(mcp): platform + user auth resolvers — reads via platform, writes via user (§19)#326
initializ-mk merged 1 commit into
mainfrom
feat/platform-token-resolver

Conversation

@initializ-mk

Copy link
Copy Markdown
Contributor

Forge's half of the platform token resolver (design §19; layer split per §18/§14.2). Fixes the deployed-agent dead end — mcp: no stored token — login required: "atlassian" — where config materialization worked (client_id + endpoints resolved) but no grant could ever exist: forge mcp login is laptop-only and there is no browser in a pod.

The constraint that shapes it

MCP auth is connection-scoped: the token is presented at initialize for the whole session — one connection = one identity. So per-operation identity materializes as per-identity server entries (same URL, split platform-side), and Forge needs only per-server auth config — the shape it already has. Two resolver types, nothing more:

platform:
  token_endpoint: ${INITIALIZ_TOKEN_ENDPOINT}
  agent_identity: ${FORGE_PLATFORM_TOKEN}

mcp:
  servers:
    - name: atlassian-read
      auth: { type: platform, ref: mcp.atlassian }
      required: true            # startup-viable: no human needed
    - name: atlassian-write
      auth: { type: user, ref: mcp.atlassian }
      required: false           # inherently lazy: no user at startup

What's here

  • auth.type=platform — agent-principal (service) identity. New top-level platform block; both fields ${VAR}-expanded at use (like token_env — rotated pod secrets apply without restart). The resolver POSTs {"server": <ref>} with the agent credential as Bearer, caches the access token to TTL (30s skew), re-fetches on expiry, and ignores any refresh_token in the response — the agent never holds one (invariant 8). Missing platform block fails at NewServer, not on the first call. The resolver host merges into the egress allowlist.
  • auth.type=user — delegated user identity, inherently lazy: required:true is rejected at both validate and NewServer; auth attempts fail with ErrNoToken + an actionable message until the platform consent flow (Epic: runtime per-user, ephemeral per-session MCP OAuth (interactive login via Slack / A2A UI) #317) completes. Never blocks startup.
  • MCPAuth.Ref — the platform tool-registry entry the token resolver authorizes against (platform-materialized; defaults to the server name).

Acceptance mapping (Forge side)

  • platform server starts with no human/login/stored token ✓ (TestNewServer_PlatformAndUserRules, resolver tests)
  • token cached to TTL, re-fetched on expiry, refresh token never held ✓ (TestPlatformTokenSource_FetchCacheAndIgnoreRefresh, _RefetchOnExpiry — the fake resolver emits a forbidden refresh_token and the source ignores it)
  • resolver's entitlement rejection surfaces ✓
  • type: user + required: true rejected at validate ✓ (TestValidateMCPConfig_ManagedIdentityTypes)
  • user server lazy, non-blocking ✓
  • air-gap regression: oauth/forge mcp login path untouched; full suites green, golangci-lint 0 issues

Platform half (Connect grant storage, POST /mcp/token with entitlement, materialization split) lands in agent-builder alongside this.

🤖 Generated with Claude Code

@initializ-mk

Copy link
Copy Markdown
Contributor Author

Merged main into this branch to resolve the conflict with the recently-merged MCP-OAuth PRs (#320 discovery/DCR, #323 env-expansion, #325 client_credentials). Merge commit ce6dcbb.

One real conflict, in forge-core/types/config.go (MCPAuth struct — both sides added fields). Resolved by keeping all of them: this branch's Ref (platform/user) alongside main's Grant + ClientSecretEnv (client_credentials), with main's updated ClientID doc comment.

Everything else auto-merged cleanly and correctly — notably buildAuthFn: this branch's signature change to (spec, deps ServerDeps) composed with main's client_credentials oauth-case logic via flow := deps.OAuth, so both the platform/user resolvers and the 2LO client_credentials grant coexist. Fixed the two buildAuthFn(spec, flow) call sites in oauth_clientcreds_test.go to pass ServerDeps{OAuth: flow}.

The two agent-principal approaches are complementary and both survive: type: platform (managed, platform-resolved — this PR) and type: oauth + grant: client_credentials (standalone 2LO — #325). Full forge-core + forge-cli suites pass, lint clean, CI green.

…s via user (§19)

Fixes the deployed-agent dead end: 'mcp: no stored token — login
required' with no browser in the pod. forge mcp login is laptop-only;
discovery/DCR give a client, not a grant. The managed answer is the
resolver seam: the agent fetches SHORT-LIVED tokens from its platform.

MCP auth is connection-scoped (one connection = one identity), so
per-operation identity materializes platform-side as per-identity
server entries; Forge gains exactly two resolver types and no
per-operation selection logic:

- auth.type=platform — agent-principal (service) identity. New
  top-level platform block {token_endpoint, agent_identity} (both
  ${VAR}-expanded at USE, so rotated pod secrets apply live). The
  resolver POSTs {server: auth.ref} with the agent credential and
  caches the access token to TTL (30s skew, singleflight via mutex);
  a refresh_token in the response is deliberately ignored — the agent
  never holds one (invariant 8). Startup-viable: no human, no login,
  no stored token. Construction fails without the platform block.
  The resolver host merges into the egress allowlist.
- auth.type=user — delegated user identity, inherently lazy (no user
  at startup): required:true rejected at validate AND NewServer; auth
  attempts fail with ErrNoToken + an actionable message until the
  platform consent flow (#317) produces a grant.
- MCPAuth.Ref names the registry entry the platform authorizes
  against (platform-materialized; defaults to the server name).

Standalone oauth / forge mcp login path untouched (air-gap
regression: existing suites unchanged and green).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@initializ-mk
initializ-mk force-pushed the feat/platform-token-resolver branch from ce6dcbb to f77d456 Compare July 17, 2026 12:59
@initializ-mk
initializ-mk merged commit 98d1f66 into main Jul 17, 2026
9 checks passed
initializ-mk added a commit that referenced this pull request Jul 17, 2026
#326 landed the type=user resolver as a pure stub — every call returned
ErrNoToken. This makes it actually resolve a per-REQUESTING-USER token
from the platform token endpoint (§19 delegated contract), the
token-acquisition core of the delegated path.

- `delegatedTokenSource`: POSTs `{server, subject}` to the platform token
  endpoint and caches the access token PER SUBJECT, so distinct users
  never share a token. The lock is not held across the network fetch, so
  a slow fetch for user A never blocks user B (the multi-user path is the
  point of #317). A platform 401/403/404 → ErrNoToken (auth-required), so
  a user without a grant stays lazy/non-blocking. Refresh token never
  reaches the agent (invariant 8).
- `buildAuthFn` type=user: reads the requesting user from
  `auth.IdentityFromContext(ctx)` (email preferred, then user id) and
  resolves that subject's token. authFn runs per-request with the
  request ctx, so a shared connection carries per-user tokens on the
  call path. No user in ctx → ErrNoToken (lazy; never at startup).
- `NewServer` type=user now requires the top-level platform block (it
  resolves via the platform endpoint), same as type=platform.
- Extracted `doPlatformTokenRequest` shared by the agent-principal and
  delegated sources; the agent-principal path (#326) is behavior-preserved.

Docs: configuration.md gains a "Managed identity (platform / user)"
section — #326 shipped both types without config docs.

Scope: this is the token-acquisition layer. The per-user CONNECTION
lifecycle (lazy establishment so `initialize` runs under a user's
session) and carrying the raw OIDC assertion for a broker-side ID-JAG
exchange are the follow-ons; the platform's vaulted-3LO path resolves on
subject alone today.

Tests: per-subject fetch+cache (distinct users → distinct tokens, same
user cached), platform-403 → ErrNoToken, buildAuthFn resolves subject
from ctx / lazy without a user. Updated #326's rules test for the new
platform-block requirement on type=user.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant